Skip to content

fix(providers): point each export condition's types at its own declaration - #717

Draft
sroussey wants to merge 17 commits into
mainfrom
claude/wonderful-turing-rjtcnx-ai-types
Draft

fix(providers): point each export condition's types at its own declaration#717
sroussey wants to merge 17 commits into
mainfrom
claude/wonderful-turing-rjtcnx-ai-types

Conversation

@sroussey

@sroussey sroussey commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

The real count: 25 branches, 9 manifests

Not ~24 ./ai entries in providers — 25 condition branches across 9 workspace manifests, and they are not all in providers/*:

where branches
providers/* (8 packages × ./ai + ./ai-runtime, minus cactus's already-correct ./ai) 15
packages/workglow (9 provider re-export shims + ./worker) 10

Affected providers: openai, ollama, xai, deepseek, openrouter, llamacpp-server, stable-diffusion-server, cactus.

Shape in providers/*: the "browser" block loads ./dist/ai.browser.js but types it with ./dist/ai.d.ts. packages/workglow's nine shims are the same shape; its ./worker is the inverse — the default branch loads worker-node.js but types it with worker-browser.d.ts.

providers/anthropic, google-gemini, huggingface-inference, huggingface-transformers, mlx, node-llama-cpp have no browser condition at all and are unaffected. tf-mediapipe, chrome-ai, cloudflare have a browser block that points at the same files as the default — redundant, but internally consistent, so untouched.

Phase 1 classification — do the surfaces actually differ?

I compared the resolved export surface (not the text) of each emitted X.d.ts against X.browser.d.ts using the TypeScript checker — the emitted files are one-line export * from … stubs, so a textual diff is meaningless.

Type-surface difference (live bug) — 7 branches:

branch node-only symbols
openai/ai _testOnly, registerOpenAiImageValidator, OpenAI_ModelSearch_Stream
ollama/ai _testOnly
ollama/ai-runtime createOllamaTextGenerationStream, createOllamaStructuredGenerationStream
xai/ai _testOnly, Xai_ModelSearch_Stream
deepseek/ai assertNotTruncatedByReasoning, resolveMaxTokens, DEEPSEEK_DEFAULT_REASONING_ALLOWANCE, DeepSeekToolChoiceNotHonoredError, assertToolChoiceHonored, isForcingToolChoice, _testOnly, DeepSeek_ModelSearch_Stream
openrouter/ai 10 symbols (_testOnly, OPENROUTER_RUN_FN_SPECS, fetchOpenRouterModels, mapOpenRouterModels, OpenRouterRawModel, OPENROUTER_FALLBACK_MODELS, …)
cactus/ai-runtime same names, different signature for getCactusModelCacheInfo

Same surface (wrong pointer, currently harmless) — 18 branches: openai/ai-runtime, xai/ai-runtime, deepseek/ai-runtime, openrouter/ai-runtime, both llamacpp-server entries, both stable-diffusion-server entries (their ai.ts and ai.browser.ts are byte-identical), and all 10 packages/workglow branches (its shims are byte-identical pass-throughs — the browser/node split really happens one layer down, in the provider's own exports map). No branch is browser-only or has a browser-superset surface: node ⊇ browser everywhere.

The difference is real at runtime, not just in the declarations. Importing both openai bundles and diffing Object.keys:

node-only runtime exports: [ "OpenAI_ModelSearch_Stream", "_testOnly", "registerOpenAiImageValidator" ]
browser-only runtime exports: []

Phase 1 — build and consumers

Per-condition declarations are already emitted; no build change is needed. Each package's tsconfig.json uses include: ["src/**/*"] with emitDeclarationOnly, so tsgo produces a .d.ts for every source file — dist/ai.browser.d.ts and dist/worker-node.d.ts all exist after a clean build. The browser .js bundles come from an existing per-package build-browser script (wired into build-js / build-package), which I initially missed by reading only build-code. Verified by listing dist/ after bun run build:packages: all eight providers and packages/workglow emit both .js and .d.ts for both targets. This is a manifest-only fix.

Nothing in-repo depends on the mismatch. customConditions: ["browser"] appears in exactly one place, examples/web/tsconfig.json, and that app does not reference any affected provider. Everything else (root tsconfig.json, tsconfig.typecheck.json, vitest) resolves under the default conditions, which are unchanged. packages/test imports _testOnly from these providers under the default condition — unaffected.

Phase 2 — decision

Point each condition's types at its own emitted declaration. The evidence rules out the cheaper options: 7 branches have genuinely different export surfaces, so "browser and node share a type surface" is factually false and documenting it would be wrong; and they should differ (the browser entries deliberately omit node-only test hooks and model-search code), so collapsing them onto one shared surface would be a product regression, not a de-drift. The fix costs nothing to build because the per-target declarations are already emitted, and I applied it uniformly to all 25 branches — including the 18 that agree today — because "identical right now" is exactly the state the seven divergent ones were in before they drifted. No package needed different treatment.

Phase 3 — the guard

packages/test/src/test/util/ExportTypesPairing.test.ts walks every condition branch (recursively, so nested conditions are covered) of every {packages,providers,examples}/*/package.json and asserts each types equals its sibling import/require/default with .js.d.ts. Three assertions: the pairing invariant; a non-vacuity check (>50 branches found, so a broken scan can't pass silently); and a staleness check on the allowlist. ALLOWED_MISMATCHES is empty and documented as such — no legitimate exception exists today.

A short paragraph was added to docs/technical/19-build-system.md's "Conditional Exports" section stating the invariant and naming the guard.

Consumer-visible type surface change — yes

For the 7 divergent branches, browser builds now see the browser type surface. Downstream code importing a node-only symbol under the browser condition will start failing to compile. That is the point of the fix: those symbols were already undefined at runtime in a browser bundle, so the old behavior was a silent runtime failure dressed up as a clean compile. The other 18 branches are a no-op for consumers.

Verified

Everything below was run in this worktree and observed to pass:

  • bun install — clean.
  • bun run build:types — 41/41 tasks successful.
  • bun run build:packages — 81/81 tasks successful.
  • npx vitest run packages/test/src/test/util/ExportTypesPairing.test.ts — 3/3 passed.
  • Negative check on the guard: reverted providers/openai's ./ai browser types back to ./dist/ai.d.ts → the test failed with the exact branch named (providers/openai/package.json exports["./ai"] [browser]: types="./dist/ai.d.ts" but implementation is "./dist/ai.browser.js"); restored, test passes again.
  • Targeted typecheck proving corrected resolution: a probe importing { OPENAI, _testOnly } from "@workglow/openai/ai", compiled twice with moduleResolution: bundler. After the fix — under customConditions: ["browser"]: TS2305: Module '"@workglow/openai/ai"' has no exported member '_testOnly'; under default conditions: exit 0. Before the fix, the browser-condition compile exited 0 — i.e. it silently accepted a symbol that does not exist in the browser bundle.
  • npx vitest run packages/test/src/test/util/ — 45 files, 669 passed / 6 skipped.
  • Provider tests that consume _testOnly at runtime (OpenAI_ReasoningTemperature, ProviderUsageNormalization, CrossProviderRefusals) — 50/50 passed, confirming runtime resolution is untouched (types is a TypeScript-only condition).
  • npx eslint on the new test — clean. prettier --check on the new test and all changed manifests — clean; docs/technical/19-build-system.md reports a pre-existing prettier warning that is present on main before my edit (unrelated table/JSON formatting), so I left it rather than adding reformatting churn.
  • git status checked after every command; no stray manifest rewrites (bun run use-source was never invoked).

Not verified: I did not run the full repo test suite (only the util section plus the three provider tests above), and I did not build a downstream browser app against the corrected types — the compile-condition probe is the evidence for that path.


🤖 Generated with Claude Code


Generated by Claude Code

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 60.96% 39687 / 65098
🔵 Statements 60.46% 41678 / 68930
🔵 Functions 61.54% 7686 / 12488
🔵 Branches 49.44% 20489 / 41434
File CoverageNo changed files found.
Generated in workflow #3216 for commit ad3209a by the Vitest Coverage Report Action

@sroussey
sroussey marked this pull request as draft August 7, 2026 21:55
@sroussey
sroussey force-pushed the claude/wonderful-turing-rjtcnx-ai-types branch from d86050a to f8a9718 Compare August 13, 2026 03:55
sroussey added a commit that referenced this pull request Aug 15, 2026
…els dropped (#795)

* fix(providers): restore the runtime-agnostic exports the browser barrels dropped

Five providers keep a hand-maintained `src/ai/index.browser.ts` beside
`src/ai/index.ts`. Four of them omitted modules that carry no
platform-specific code, so a `customConditions: ["browser"]` consumer
could not import them at all.

The omissions were invisible until the exports-map fix in #717 routed
browser consumers to the browser declarations; they now read as `TS2305`.
That they are accidental is evidenced by the bundles themselves: every
omitted module is ALREADY compiled into that provider's browser bundle
via the runtime entry (e.g. `registerOpenAi`, which IS in the browser
barrel, imports `registerOpenAiImageValidator`), and no `src` tree of the
five contains a single `node:` import. Only the `export *` line was
missing.

Restored, per provider:

- deepseek: `assertNotTruncatedByReasoning`,
  `DEEPSEEK_DEFAULT_REASONING_ALLOWANCE`, `resolveMaxTokens`
  (`DeepSeek_Client`); `DeepSeek_ModelSearch_Stream`
  (`DeepSeek_ModelSearch`); `DeepSeekToolChoiceNotHonoredError`,
  `assertToolChoiceHonored`, `isForcingToolChoice`
  (`DeepSeek_ToolCalling`)
- openai: `registerOpenAiImageValidator` (`OpenAI_ImageValidation`);
  `OpenAI_ModelSearch_Stream` (`OpenAI_ModelSearch`)
- xai: `Xai_ModelSearch_Stream` (`Xai_ModelSearch`)
- openrouter: `openRouterWorkerRunFnSpecs`, `deriveCapabilitiesFromMeta`,
  `inferOpenRouterCapabilities`, `OPENROUTER_RUN_FN_SPECS`
  (`OpenRouter_Capabilities`); `OpenRouterRawModel`,
  `OPENROUTER_FALLBACK_MODELS`, `fetchOpenRouterModels`,
  `mapOpenRouterModels`, `OpenRouter_ModelSearch_Stream`
  (`OpenRouter_ModelSearch`)

`DeepSeek_ToolCalling` keeps the NAMED form the node barrel uses, which
is what holds `DeepSeek_ToolCalling_Stream` out of the main-thread barrel
on both platforms.

ollama is deliberately unchanged: its `Ollama_ModelSearch` is exported by
NEITHER barrel, so the two agree, and `_testOnly` is its only delta.
`_testOnly` stays node-only everywhere — it is `@internal`, for
`@workglow/test` alone, and belongs behind a `./test` entry the way
`packages/ai` already did it.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(providers): re-export the node entry from the browser entry where no split exists

`providers/llamacpp-server` and `providers/stable-diffusion-server` each
carried a `src/ai.browser.ts` byte-identical to `src/ai.ts` and a
`src/ai-runtime.browser.ts` byte-identical to `src/ai-runtime.ts`, all
four naming RELATIVE specifiers (`./ai/index`, `./ai/runtime`).

A relative specifier is resolved once, by the importing file's own path,
and nothing in this toolchain substitutes `X.browser.ts` for `X.ts` on
one: `--target=browser` changes the compile target, not the resolver, and
a manifest's `browser` field applies to bare specifiers. So both entries
already pulled in the same module graph — the declaration split was
nominal, two `.d.ts` files kept equal only by hand.

Each `.browser.ts` now re-exports its node peer, which cannot drift.
These packages keep their `browser` condition: `--target=browser`
produces a genuinely different bundle, so the entry earns its keep even
though the source graph is shared. Bundle output is unchanged, byte for
byte, for all four entries.

The nine `packages/workglow/src/*.browser.ts` shims look like the same
shape and are deliberately left alone — they re-export a BARE specifier
(`@workglow/openai/ai`), which is re-resolved under the consumer's own
conditions at every hop, so the two identical files land on different
modules and their being identical IS the mechanism.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(exports): flag a duplicate browser entry and pin browser barrel parity

Three guards, all source-only (no build, works under `use-source`):

- `duplicateBrowserEntryViolations` (ExportTypesPairing.test.ts) reports
  a `src/*.browser.ts` identical to the `src/<stem>.ts` beside it whose
  specifiers are ALL relative. The relative/bare distinction is the whole
  rule: a relative specifier resolves once, so both entries are the same
  module graph and the split is nominal; a bare one is re-resolved under
  the consumer's conditions at every hop, so two identical files land on
  different modules — which is why the `packages/workglow` shims are
  correctly identical and must never be reported. Both branches carry a
  fixture, since no violation survives in the tree.

- `ExportBarrelParity.test.ts` parses the top-level re-exports of each
  provider's `src/ai/index.ts` and `src/ai/index.browser.ts` and asserts
  `node \ browser` equals a pinned `INTENTIONAL_NODE_ONLY` fixture
  (`_testOnly` for each of the five providers), with a staleness check so
  a pin that stops describing a real difference fails. The parser is
  regex-based, so an unclassifiable statement is REPORTED rather than
  skipped — the same call `buildEntryViolations` makes for an underivable
  dist stem.

- `findBrowserBlock` replaces a top-level `?.browser` lookup in
  `browserSplitViolations`, which disagreed with its own recursing
  sibling `nodeImportTarget`: a `{ import: { browser: {…}, default: … } }`
  resolved its node target through the recursion while the browser block
  sat one level down, invisible to every rule keyed on it. Fixture added
  for the no-implementation case reached that way.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
claude and others added 8 commits August 16, 2026 16:29
…ation

Twenty-five condition branches across nine workspace manifests declared a
`types` target that did not belong to the implementation named beside it.
Fifteen are in `providers/*` (openai, ollama, xai, deepseek, openrouter,
llamacpp-server, stable-diffusion-server, cactus), where a `"browser"` block
loaded `dist/ai.browser.js` but typed it with `dist/ai.d.ts`; ten are in
`packages/workglow` (the nine provider re-export shims plus `./worker`, whose
default branch typed `worker-node.js` with `worker-browser.d.ts`).

The mismatch is not cosmetic for all of them. Comparing the resolved export
surfaces of the emitted declarations shows seven provider branches where the
browser build genuinely exports less than the node build — `_testOnly`,
`registerOpenAiImageValidator` and `OpenAI_ModelSearch_Stream` (openai/ai),
`_testOnly` (ollama/ai), two stream factories (ollama/ai-runtime), the
reasoning and tool-choice helpers (deepseek/ai), ten model-search symbols
(openrouter/ai), two (xai/ai), and a changed `getCactusModelCacheInfo`
signature (cactus/ai-runtime). Importing `@workglow/openai/ai` under the
`browser` condition compiled clean against `_testOnly`; the browser bundle's
runtime exports do not include it. The remaining eighteen branches share a
surface today and are corrected for consistency, since "identical right now"
is exactly how the seven drifted.

No build change is needed: `tsgo` already compiles all of `src/**/*`, so every
`*.browser.d.ts` is emitted, and the browser bundles come from the existing
per-package `build-browser` script. This is a manifest-only fix.

`ExportTypesPairing.test.ts` walks every condition branch of every workspace
manifest and asserts each `types` pairs with the implementation beside it, with
an empty, documented allowlist so the class of drift cannot return silently.

Consumer-visible change: browser builds of the seven differing entries now see
the browser type surface, so code importing a node-only symbol under the
`browser` condition starts failing to compile — correctly, since that symbol
was already `undefined` at runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The exports-map guard only recorded a branch when the node was an object that
already had a `types` string, so the two shapes that trigger the very defect it
guards against slipped past it silently:

- `"browser": "./dist/ai.browser.js"` (string shorthand) returned at the
  `typeof node !== "object"` bail-out; and
- `"browser": { "import": "./dist/ai.browser.js" }` (object, no `types`) was
  walked past.

Either one resolves through to the OUTER `types`, type-checking browser
consumers against the node build — the mismatch this guard exists to catch —
with the suite green. A branch that names an implementation must now declare
the declaration beside it, whether it declares a wrong one or none at all; both
kinds feed the single `mismatched` array, so the assertion and the
`ALLOWED_MISMATCHES` semantics are unchanged.

`declarationFor` also derived the wrong extension for `.cjs`/`.mjs`: it mapped
both to `.d.ts`, so a correct node16 manifest pairing `"require":
"./dist/x.cjs"` with `"types": "./dist/x.d.cts"` would have failed and pushed
the author toward the allowlist or toward an extension TypeScript will not
honor. `.cjs` is described by `.d.cts` and `.mjs` by `.d.mts`. No manifest uses
either extension today; this is pre-emptive.

The walk also now descends into an implementation key holding an object — the
`{ "import": { "types": …, "default": … } }` dual-package form — which it
previously skipped wholesale, so that shape was unchecked for the same reason.

Because no manifest violates any of these rules, the new code paths would ship
untested. The walk is extracted into `findViolations(manifest, exportsMap)`,
used by both the repo scan and five fixtures that exercise each path directly:
the two shapes above (one violation each, naming `ai.browser`), a correct
`.cjs`/`.d.cts` and `.mjs`/`.d.mts` pairing (none), a `.cjs` declared by a
`.d.ts` (one), and `"./package.json": "./package.json"`, which the module-path
test keeps out of the missing-declaration bucket (none). The `> 50` vacuity
guard on the real scan is unchanged; the scan still reports 0 violations over
163 collected branches.

Two smaller cleanups: `repoRoot` used `new URL(import.meta.url).pathname`,
which yields a wrong root for a percent-encoded path, and is now
`fileURLToPath` like the other tests in this package; and the workspace group
list was hardcoded `["packages", "providers", "examples"]`, so a fourth group
would have been dropped from the scan with no failure — it is now derived from
the root manifest's own `workspaces` globs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6huUY7hSkRbjun1P9HKsz
Correct `exports` manifests pair each condition's `types` with the
implementation beside it, but TypeScript's own condition set is
["import", "types"] under moduleResolution "bundler" and
["node", "import", "types"] under node16/nodenext — "browser" is in
neither. A browser app therefore bundles dist/browser.js while tsc
type-checks it against the node declarations unless the consumer sets
customConditions: ["browser"].

Document that in the Conditional Exports section and in the
multi-runtime resolution rules, and pin examples/web's opt-in with an
assertion so deleting that one line fails loudly instead of silently
reverting the example project to node declarations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oVdDSRMJeALBPLDQf3DgH
`ExportTypesPairing.test.ts` only compared a branch's `types` string to the
implementation string beside it, so a branch could be internally consistent
and still name files nothing builds. Copying another package's `browser`
block into a manifest that has no `*.browser.ts` declares a matching
`.d.ts`/`.js` pair for a build that does not exist, and every existing
assertion passes.

Two repo-wide assertions close that:

- every `types` and implementation target must derive to a `src/<stem>.ts`
  (or `.tsx`) that exists; a target whose layout the derivation cannot
  describe is reported rather than skipped.
- `types` must precede the implementation key in the same object, since Node
  stops at the first matching condition.

The `src` correlation is a proxy for "the build emits this", not a check of
each package's build-script entry list: a source file that exists but was
never added to `build-code` still passes. It catches the copy-paste case.

All 163 branches across the workspace pass both today, so no manifest
changes. Also corrects three comments: a branch with no `types` is not typed
by an outer one (resolution stops at the matched branch and TypeScript looks
beside the resolved file), and `ALLOWED_MISMATCHES` silences missing
declarations too, not just mismatched ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDvMMv78PuEw4T5atLeJeS
Two coverage gaps in the exports guard. Both are latent — no workspace manifest
violates either rule today (168 branches across 38 manifests, 0 new violations),
which is exactly why they went unnoticed.

Only the first implementation key per object was examined.
`IMPLEMENTATION_KEYS.find(...)` stopped at `import`, and the recursion skipped
string-valued implementation keys, so a flat dual-package object
`{types, import: "./a.js", require: "./a.cjs"}` never had its `require` paired
against anything — a `.cjs` declared by a `.d.ts` sailed through. The file's own
`.cjs` fixtures use the nested `require: {types, default}` form, which is what
hid it. Now every string-valued implementation key yields a branch.

That makes one object produce several branches, which would collide in `label()`
— the key for ALLOWED_MISMATCHES and the staleness check — so the
implementation key joins the branch identity and the label reads
`[condition > key]`. The shorthand form keeps its bare `[condition]`: its value
IS the implementation, so there is no key to name. ALLOWED_MISMATCHES ships
empty, so no allowlist migration is needed; only failure text changes.

Condition ORDER across sibling keys was never checked. `typesBeforeImplementation`
compares indices within one object, so a map whose branches are each internally
well formed but ordered `{types, browser: {…}, import}` passes every existing
check while TypeScript matches the outer `types` and never looks at `browser` —
the browser-typed-as-node bug this file exists to prevent, expressed through
ordering rather than through a wrong target. `{import, browser: {…}}` is the
runtime equivalent. Adds `orderViolations`, covering object and string-shorthand
condition keys alike, and asserts it over every workspace manifest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H797qbH356jjznKgUax63o
…756)

Every check in ExportTypesPairing is internally self-consistent: it
compares a branch against itself. A `browser` block that duplicates the
default branch therefore passes all six while routing browser consumers
to the NODE bundle.

Adds `browserSplitViolations`, keyed on the one piece of evidence that
tells a legitimate duplicate from a bug — whether a `<stem>.browser.ts`
source entry exists beside it. The probe is injected so fixtures drive
both sides. chrome-ai and tf-mediapipe have no such entry, so their
duplicate blocks stay green and are left untouched; the rule fires the
moment someone adds the source entry.

Also widens `orderViolations`: it counted only string-valued
implementation keys as shadowers, so a nested `"import": { default: … }`
could hide a later `"browser"` unflagged. An object counts when it
resolves unconditionally; one with no unconditional target does not,
because Node falls through it.


Claude-Session: https://claude.ai/code/session_01RomTUtZSTgUbFCYqFs4pcu

Co-authored-by: Claude <noreply@anthropic.com>
Both are cases where the guard test does not guard what it claims.

A `browser` block declaring only `types` was invisible to ALL SIX rules.
`collectBranches` records a branch only when a string-valued implementation
key is present, so such a block produces no branch at all — not even for
the source-entry rule. Bundlers, meanwhile, enter the `browser` condition,
match nothing, and fall through to the outer `import`, bundling the node
build while tsc (customConditions ["browser"]) types against the browser
declarations. That is exactly the browser-typed-as-node inversion this file
exists to prevent, reached by omission rather than by a wrong target.

`browserSplitViolations` now decides the empty-implementation case BEFORE
the browser-source-entry guard. Hoisting past that guard is deliberate: the
`packages/*` layout (stem `node`, no `src/node.browser.ts`) never reaches
the guarded code, so the bug would otherwise go unreported for every
package in the repo. Fixtures cover the openai manifest with its browser
`"import"` deleted, the `packages/storage` layout with the `never` probe,
and a healthy block that must stay silent — the first two assert
`findViolations` and `orderViolations` are `[]` first, the go-red proof.

"Source entry exists" proved only the `.d.ts` half. `build-types` runs
`tsgo` over the whole `src` tree, so the file's existence is what makes the
declaration appear; the `.js` comes from hand-written entry lists in each
package's `build*` scripts, which nothing read. Add
`providers/foo/src/ai.browser.ts` and a `browser` block, forget to append
`./src/ai.browser.ts` to `build-browser`, and every check passed while
`dist/ai.browser.js` was never emitted.

`buildEntryViolations` collects every `.js`/`.cjs`/`.mjs` implementation
target, derives its source entry from the dist stem, and requires a
whole-token match in the joined text of the package's `build*` scripts — so
duckdb's nested `--outdir` matches and `./src/ai.browser.ts` does not
satisfy the stem `ai`. Unrecognized layouts are reported, not skipped.
Zero violations across every manifest today, with exactly one exemption:
`packages/workglow`, whose build is glob-driven. That exemption is pinned
in `GLOB_BUILT_PACKAGES` and guarded by a test asserting the package really
does hand its build to a repo-local `*.ts` program, so it dies if the
package goes back to naming its entries.

This lives in the test file that reviews the manifest rather than in a
`publish-workspaces.ts` prepack assertion: a prepack assertion fires after
review and after merge, and a guard that does not guard has to be able to
fail the PR that introduces it.

Also corrects the comment above "declares only targets a source entry file
can emit", which claimed the source entry was the cheapest evidence the
build produces the target at all — true of the declaration only.
…els dropped (#795)

* fix(providers): restore the runtime-agnostic exports the browser barrels dropped

Five providers keep a hand-maintained `src/ai/index.browser.ts` beside
`src/ai/index.ts`. Four of them omitted modules that carry no
platform-specific code, so a `customConditions: ["browser"]` consumer
could not import them at all.

The omissions were invisible until the exports-map fix in #717 routed
browser consumers to the browser declarations; they now read as `TS2305`.
That they are accidental is evidenced by the bundles themselves: every
omitted module is ALREADY compiled into that provider's browser bundle
via the runtime entry (e.g. `registerOpenAi`, which IS in the browser
barrel, imports `registerOpenAiImageValidator`), and no `src` tree of the
five contains a single `node:` import. Only the `export *` line was
missing.

Restored, per provider:

- deepseek: `assertNotTruncatedByReasoning`,
  `DEEPSEEK_DEFAULT_REASONING_ALLOWANCE`, `resolveMaxTokens`
  (`DeepSeek_Client`); `DeepSeek_ModelSearch_Stream`
  (`DeepSeek_ModelSearch`); `DeepSeekToolChoiceNotHonoredError`,
  `assertToolChoiceHonored`, `isForcingToolChoice`
  (`DeepSeek_ToolCalling`)
- openai: `registerOpenAiImageValidator` (`OpenAI_ImageValidation`);
  `OpenAI_ModelSearch_Stream` (`OpenAI_ModelSearch`)
- xai: `Xai_ModelSearch_Stream` (`Xai_ModelSearch`)
- openrouter: `openRouterWorkerRunFnSpecs`, `deriveCapabilitiesFromMeta`,
  `inferOpenRouterCapabilities`, `OPENROUTER_RUN_FN_SPECS`
  (`OpenRouter_Capabilities`); `OpenRouterRawModel`,
  `OPENROUTER_FALLBACK_MODELS`, `fetchOpenRouterModels`,
  `mapOpenRouterModels`, `OpenRouter_ModelSearch_Stream`
  (`OpenRouter_ModelSearch`)

`DeepSeek_ToolCalling` keeps the NAMED form the node barrel uses, which
is what holds `DeepSeek_ToolCalling_Stream` out of the main-thread barrel
on both platforms.

ollama is deliberately unchanged: its `Ollama_ModelSearch` is exported by
NEITHER barrel, so the two agree, and `_testOnly` is its only delta.
`_testOnly` stays node-only everywhere — it is `@internal`, for
`@workglow/test` alone, and belongs behind a `./test` entry the way
`packages/ai` already did it.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(providers): re-export the node entry from the browser entry where no split exists

`providers/llamacpp-server` and `providers/stable-diffusion-server` each
carried a `src/ai.browser.ts` byte-identical to `src/ai.ts` and a
`src/ai-runtime.browser.ts` byte-identical to `src/ai-runtime.ts`, all
four naming RELATIVE specifiers (`./ai/index`, `./ai/runtime`).

A relative specifier is resolved once, by the importing file's own path,
and nothing in this toolchain substitutes `X.browser.ts` for `X.ts` on
one: `--target=browser` changes the compile target, not the resolver, and
a manifest's `browser` field applies to bare specifiers. So both entries
already pulled in the same module graph — the declaration split was
nominal, two `.d.ts` files kept equal only by hand.

Each `.browser.ts` now re-exports its node peer, which cannot drift.
These packages keep their `browser` condition: `--target=browser`
produces a genuinely different bundle, so the entry earns its keep even
though the source graph is shared. Bundle output is unchanged, byte for
byte, for all four entries.

The nine `packages/workglow/src/*.browser.ts` shims look like the same
shape and are deliberately left alone — they re-export a BARE specifier
(`@workglow/openai/ai`), which is re-resolved under the consumer's own
conditions at every hop, so the two identical files land on different
modules and their being identical IS the mechanism.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(exports): flag a duplicate browser entry and pin browser barrel parity

Three guards, all source-only (no build, works under `use-source`):

- `duplicateBrowserEntryViolations` (ExportTypesPairing.test.ts) reports
  a `src/*.browser.ts` identical to the `src/<stem>.ts` beside it whose
  specifiers are ALL relative. The relative/bare distinction is the whole
  rule: a relative specifier resolves once, so both entries are the same
  module graph and the split is nominal; a bare one is re-resolved under
  the consumer's conditions at every hop, so two identical files land on
  different modules — which is why the `packages/workglow` shims are
  correctly identical and must never be reported. Both branches carry a
  fixture, since no violation survives in the tree.

- `ExportBarrelParity.test.ts` parses the top-level re-exports of each
  provider's `src/ai/index.ts` and `src/ai/index.browser.ts` and asserts
  `node \ browser` equals a pinned `INTENTIONAL_NODE_ONLY` fixture
  (`_testOnly` for each of the five providers), with a staleness check so
  a pin that stops describing a real difference fails. The parser is
  regex-based, so an unclassifiable statement is REPORTED rather than
  skipped — the same call `buildEntryViolations` makes for an underivable
  dist stem.

- `findBrowserBlock` replaces a top-level `?.browser` lookup in
  `browserSplitViolations`, which disagreed with its own recursing
  sibling `nodeImportTarget`: a `{ import: { browser: {…}, default: … } }`
  resolved its node target through the recursion while the browser block
  sat one level down, invisible to every rule keyed on it. Fixture added
  for the no-implementation case reached that way.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
@sroussey
sroussey force-pushed the claude/wonderful-turing-rjtcnx-ai-types branch from 6efb8a2 to d02a046 Compare August 16, 2026 16:31
claude and others added 8 commits August 16, 2026 16:35
Both guards landed in #717 look only at the layer they were written for, and
the same defect class survives one level below.

`ExportBarrelParity` keyed on the hard-coded `src/ai/index{,.browser}.ts` pair,
so it never saw `src/ai/runtime.browser.ts` — the barrel a browser consumer
gets under `@workglow/<vendor>/ai-runtime`. It now walks `providers/<vendor>/src`
recursively and pairs every `<stem>.browser.ts` with the `<stem>.ts` beside it
(~53 pairs, against 5 before). Three things that widening needs:

- Specifier keys strip a trailing `.browser`, or every runtime pair reports as
  total drift (`* from "./common/Ollama_Client"` against
  `* from "./common/Ollama_Client.browser"`) and the guard says nothing. That
  makes a star-export comparison across a `.browser` sibling NOMINAL — and the
  recursive scan is what closes it, since `Ollama_Client.browser.ts` vs
  `Ollama_Client.ts` is itself a compared pair now.
- A browser file whose entire surface is `* from "./<nodeStem>"` is skipped: it
  IS the node surface, and it is the shape #717 wants (llamacpp-server,
  stable-diffusion-server), which the widened scan now reaches.
- `INTENTIONAL_NODE_ONLY` is rekeyed from the package dir to the browser FILE
  path, since a package now contributes several pairs.

The vacuous-pass assertion (`pairs` equals the pinned keys) is removed rather
than adjusted: 53 pairs against 5 pins makes equality flatly wrong, and any
form of it means a correct new provider fails until somebody registers it as
needing no exemption. Its two roles are stated separately instead — the scan
found the tree, and it recursed into it.

`ExportTypesPairing`'s `duplicateBrowserEntryViolations` scan is likewise
recursive over `src` rather than a flat `readdirSync`. The relative/bare
distinction is unchanged and is still the whole rule; all nine
`packages/workglow` bare-specifier shims still classify BARE and stay
unreported.

Scope stays at `providers/` deliberately: `packages/` pulls in
`packages/tasks/src/task/image/imageTextRender.browser.ts`, a genuine
implementation split (one factory against the node module's fifteen names)
needing a fifteen-name exemption for no benefit.

This commit is intentionally red. It reports exactly two real defects, fixed
in the commit that follows:

  providers/ollama/src/ai/runtime.ts exports
  * from "./common/Ollama_StructuredGeneration", * from "./common/Ollama_TextGeneration"
  but providers/ollama/src/ai/runtime.browser.ts does not

  providers/openrouter/src/ai/runtime.browser.ts is identical to
  providers/openrouter/src/ai/runtime.ts and names only relative specifiers,
  so both entries resolve the same module graph and the declaration split is
  nominal

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
…ds found

Both are the defect class #717 fixed at the entry layer, surviving one
directory down in `src/ai/`.

**ollama** — `runtime.ts` exports `Ollama_TextGeneration` and
`Ollama_StructuredGeneration`; `runtime.browser.ts` did not. This is a
source-compatibility fix, not a cosmetic one: before #717 the browser `types`
condition pointed at the NODE declarations, so a browser consumer importing
`createOllamaTextGenerationStream` from `@workglow/ollama/ai-runtime` compiled
fine and got `undefined` at runtime. With #717's accurate types it becomes a
hard TS2305 for anyone with `customConditions: ["browser"]`. Restoring the two
`export *` lines is what makes the declaration honest.

Neither module has a `.browser` variant, and neither imports a node builtin —
both are already compiled into the browser bundle via
`Ollama_JobRunFns.browser`. Verified: the rebuilt `ai-runtime.browser.js`
gains no code, only the two names in the entry's export list (and a module
reordering, since they are now direct entry re-exports).

Every other provider's `runtime.browser.ts` was audited against its
`runtime.ts` — deepseek, openai, openrouter and xai are in parity modulo the
`.browser` specifier suffix. ollama was the only omission.

**openrouter** — `runtime.browser.ts` was a byte copy of `runtime.ts` naming
only RELATIVE specifiers, so both already resolved the same module graph and
the declaration split was nominal: two `.d.ts` files kept equal by hand. It now
re-exports its node peer, in the shape #717 established for
`providers/llamacpp-server`. Rebuilt `ai-runtime.browser.js` is byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lgxtp7mQECdh7F2UT9CVwN
…d74u5n-export-guard-depth

test(exports): widen both export guards one directory down, and fix what they find
`duplicateBrowserEntryViolations` exempts an identical browser/node pair whose
specifiers are all BARE, on the ground that the split happens one layer down.
That is a claim about the TARGET, and nothing resolved it — so a shim, its
`browser` block, its bundle and its declarations could all exist while
resolving browser consumers to the NODE build, with every rule in this file
silent.

That shape is in the tree today. Of the nine `packages/workglow` shims,
deepseek / ollama / openai / openrouter / xai do split, but
`@workglow/anthropic/ai`, `@workglow/google-gemini/ai`,
`@workglow/huggingface-inference/ai` and `@workglow/huggingface-transformers/ai`
expose only `{types, import}` — no `browser` condition at all. `@workglow/mlx`
has none either, so the reviewer's scenario (a new shim shipping a `browser`
condition that resolves browser consumers to the node build) is reachable now,
not hypothetical.

`inertBareShimViolations` asks the question the exemption assumes the answer
to. Its probe is deliberately NOT `"browser" in exports`: `providers/chrome-ai`
and `providers/tf-mediapipe` declare a `browser` block that names the NODE
target — honest there, since each ships one bundle — and a presence test would
call that a split. `subpathSplit` compares the declared implementations against
`nodeImportTarget` instead, so a split exists only when a browser consumer
lands somewhere a node consumer does not. ONE splitting specifier keeps the
shim; a pair naming no bare specifier belongs to the sibling rule; and an
`unknown` target is REPORTED rather than skipped, for the same reason an
underivable dist stem is — a target the rule could not resolve is where an
inert shim would hide.

The pair scan is hoisted to a module-level `entryPairs` since two tests now
read it, and its count assertion is loosened to a lower bound: the exact number
moves whenever a shim or provider entry lands, and the surviving shims are the
negative case this rule must keep passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
`@workglow/anthropic/ai`, `@workglow/google-gemini/ai`,
`@workglow/huggingface-inference/ai` and `@workglow/huggingface-transformers/ai`
expose only `{types, import}` — no `browser` condition at all — so the meta-
package's browser shims for them, their `browser` blocks, their bundles and
their declarations were four artifacts byte-identical to the node ones, and the
`types` repoint was a no-op.

Resolution-neutral, verified by hand: today a browser consumer of
`workglow/anthropic` resolves `dist/anthropic.browser.*` →
`export * from "@workglow/anthropic/ai"` → (no browser block) →
`providers/anthropic/dist/ai.*`. After removal it resolves `dist/anthropic.*` →
the same bare specifier → the same module, one hop earlier.

The four subpaths now carry the two-key shape `./tf-mediapipe` and
`./chrome-ai` already use. No build-script change is needed —
`packages/workglow/build.ts` globs `src/*.ts` — and nothing else imports the
deleted files, including `src/{common,browser,node}.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
…oviders

`providers/llamacpp-server` and `providers/stable-diffusion-server` have no
platform-specific source — `src/ai/` contains no `.browser.ts` — and their
`src/ai.browser.ts` was `export * from "./ai"`. A relative specifier is
resolved once by the file's own path, so the browser bundle was the same module
graph and the split was nominal: two extra bundles and two extra declaration
files kept equal only by construction.

Both `.browser.ts` entry files, both `"browser"` blocks and the
`watch-browser` / `build-browser` scripts are gone, and the two aggregators now
match `providers/anthropic` verbatim.

Verified guard by guard: `browserSplitViolations` reaches its empty-block branch
only when `findBrowserBlock` returns something and then continues at the
source-entry probe, so removing BOTH halves keeps it green;
`buildEntryViolations` now sees only `./dist/ai.js` and `./dist/ai-runtime.js`,
both named by `build-code`; and `ExportBarrelParity`'s pair count is unchanged,
since those four were already filtered out by `isSiblingReExport`.

That helper's doc named these two packages as the shape it wants;
`providers/openrouter/src/ai/runtime.browser.ts` is the one live instance left,
so it now names that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
The check computed only `node.surface \ browser.surface` while its title
claimed symmetry, so a name the BROWSER barrel exports and the node one does
not was invisible — the same defect mirrored, and the one that hurts a browser
consumer, who compiles against a symbol the node build cannot supply.

Re-running the guard's own parser with the direction reversed found exactly one
real case: `providers/ollama/src/ai/common/Ollama_JobRunFns.browser.ts`
re-exporting `getClient`, `getModelName` and `loadOllamaSDK` from
`./Ollama_Client.browser`.

That line is DEAD, not intentional, so it is deleted rather than pinned:
`runtime.browser.ts` already does `export * from "./common/Ollama_Client.browser"`,
so all three names reach `@workglow/ollama/ai-runtime` regardless;
`ai/index.browser.ts` never re-exports the run-fns module, so
`@workglow/ollama/ai` never saw them; and nothing imports them from that path
(only `OLLAMA_RUN_FNS` is imported from it). Pinning would have installed a
permanent exemption for a redundant line, against this file's own stated
philosophy.

`INTENTIONAL_BROWSER_ONLY` therefore ships empty and documented, exactly as
`ALLOWED_MISMATCHES` does, with the deleted line recorded and a warning not to
add an entry to silence drift. The staleness test loops both maps.

Three inline fixtures exercise the comparison directly, since no pair in the
tree differs either way. The first is the go-red proof: the old one-direction
check returned `[]` for exactly that input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
Every browser-split guard in the repo reads TEXT: `ExportTypesPairing` walks
`exports` maps, `ExportBarrelParity` diffs `export` statements. An `export *`
dropped from BOTH halves of a pair keeps barrel parity, keeps the manifest
self-consistent, builds clean, and surfaces only when a downstream browser app
upgrades and gets `TS2305`. Nothing in CI resolved a provider subpath under
`customConditions: ["browser"]` — `examples/web/tsconfig.json` sets the
condition but references no affected provider.

`packages/test/src/browser-conditions/browserConditionResolution.types.ts` is a
type-only fixture that never runs; the assertion is that it RESOLVES and
COMPILES. Positives name a symbol each split subpath must still export; the
negatives are what make them mean anything — `@ts-expect-error` on the node-only
`_testOnly`, so a `customConditions` that silently stopped applying turns every
one into `TS2578` rather than passing against the node declarations. Verified by
deleting `customConditions` from the program: seven `TS2578`.

It compiles as its own program (`tsconfig.browser-conditions.json`, `noEmit`),
never as part of `packages/test`'s `build-types`, which runs under node
conditions where every negative control would fail the build —
`packages/test/tsconfig.json` excludes the directory for that reason. `workglow`
joins `packages/test`'s devDependencies because the fixture also checks the
meta-package's five splitting shims, the two-hop case no other program covers.

The subpath list is not hand-maintained: a guard derives the expected set from
the `providers/*` and `packages/workglow` manifests via `subpathSplit` — every
subpath that really splits, no exemption list — so a new provider shipping a
browser split fails until the fixture names it. A second guard pins both halves
of the wiring, since a script nothing calls and a workflow step naming a script
that does not exist each disable the check silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJRf3YFa8DjmjsZvXz8xDT
test(exports): close the gaps the export guards left — resolve shim targets, compare both directions, compile the browser condition
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants